# 子进程 child
30 秒速记
child_process让Node去跑别的程序:shell命令、二进制、另一个Node脚本- 四个入口:
spawn(流式)、exec(走shell、缓冲输出)、execFile(不走shell)、fork(Node子进程 +IPC) - 有外部输入就用参数数组 + 不开
shell,别拼命令字符串,否则就是命令注入 - 成功的标准是退出码
0加上产物存在,光「启动了」不算;error、stderr、close、超时都要处理 - 父进程退出时子进程不一定跟着退,后台任务要想清楚谁来回收
child_process 就是让 Node 去调用别的程序,四个 API 的区别在于走不走 shell、输出是流还是一次性给。 需要实时拿输出、输出量大,用 spawn;跑一条短命令要完整结果,用 exec 或 execFile;要跑另一个 Node 脚本还要互相发消息,用 fork。实际项目里最常见的两个坑:一是把用户输入拼进 exec 的命令字符串,等于开了命令注入的口子;二是只管启动不管结果,命令不存在、中途失败、超时卡住,都被当成成功返回了。
理解「子进程 child」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 process 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
面试官追问
追问 1「生成报告」接口启动子进程后马上返回成功,偶尔报告不存在,问题在哪?
接口在子进程结束前就返回了,失败的情况根本没人看。要么等 close 拿到退出码 0 再返回成功,要么接口只返回「已受理」和任务 id,前端轮询任务状态。
追问 2子进程卡住不退,接口一直挂着,怎么兜底?
一定要加超时。exec 和 execFile 自带 timeout 选项,spawn 可以传 AbortSignal:
追问 3把子进程调用包成 Promise,最少要处理哪几个事件?
error(启动失败,比如命令不存在)、close(拿退出码和信号),再收集一下 stderr 用于报错信息。close 里 code !== 0 就 reject,把 stderr 带上。
追问 4只是读个目录、压缩个文件,有必要起子进程吗?
一般没必要。fs、zlib 能做的就别调 ls、gzip,省掉平台差异和 PATH 问题,Windows 上也能跑。子进程适合调真正只有外部程序才有的能力,比如 ffmpeg、git。
追问 5本地好好的,CI 上子进程报 ENOENT,先查什么?
ENOENT 基本就是命令找不到。先看 CI 镜像里有没有装这个程序,再看 PATH 和 cwd 对不对。命令依赖 shell 别名或者 nvm 初始化的,在非交互环境里都会失效。
# 模块概览
30 秒速记
child_process=Node调外部程序、把重活交给别的进程的入口,CLI工具、构建脚本离不开它- 最基本的
spawn('ls', ['-lh', '/usr']):命令和参数分开传,返回一个ChildProcess - 输出走流:
child.stdout、child.stderr的data事件,一块一块到,不保证按行 - 结束看
close的code:0成功,非0失败,被信号杀掉时code是null、signal有值 - 收到过
stdout不代表成功,最后要以退出码为准
child_process 是 Node 调用外部程序的模块,最基础的用法就是 spawn 起一个进程,监听它的输出和退出。 拿 spawn('ls', ['-lh', '/usr']) 来说,命令和参数分开传,输出通过 stdout、stderr 两个流一块块推过来,进程结束时 close 事件给你退出码。这里有两点要记住:data 事件给的是 Buffer 块,不是一整行,要按行处理得自己拼;判断成败只看退出码,前面打了一堆正常输出,最后也可能以非零码失败。
在node中,child_process这个模块非常重要。掌握了它,等于在node的世界开启了一扇新的大门。
举个简单的例子:
const spawn = require('child_process').spawn;
const ls = spawn('ls', ['-lh', '/usr']);
ls.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
ls.stderr.on('data', (data) => {
console.log(`stderr: ${data}`);
});
ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});
面试官追问
追问 1stdout 已经收到数据了,能不能直接判定命令成功?
不能。很多命令是先输出一部分再失败的,比如编译跑到一半报错。成败要等 close 拿 code,0 才算成功。
追问 2data 回调里按 \n 切分,偶尔一行被切成两半,为什么?
data 是按缓冲区大小分块的,不按行。按行处理用 readline 包一下:
追问 3close 回调里 code 是 null,是什么情况?
进程是被信号终止的,比如超时被 SIGTERM 杀掉或者被 OOM 干掉,这时第二个参数 signal 有值。日志里要把 code 和 signal 一起打出来。
追问 4线上只记了退出码 2,stderr 是空的,还能查什么?
先确认 stderr 监听真的注册了、日志没被截断。有些程序把错误写到 stdout,两路都要收。再用相同参数、相同用户在机器上手动跑一遍,看命令自己的错误码文档里 2 代表什么。
追问 5exit 和 close 事件有什么区别,用哪个?
exit 是进程退出,但 stdio 流可能还没读完;close 是进程退出且所有流都关了。要拿完整输出就用 close。
# 几种创建子进程的方式
30 秒速记
- 四种异步方式:
spawn、exec、execFile、fork,每个都有同步版(fork除外) exec、execFile、fork底层都是spawn,差别在默认参数和封装exec=spawn+ 开shell+ 缓冲输出 + 回调;execFile= 不开shell的exec;fork= 跑Node脚本 +IPC- 怎么选:要实时流 →
spawn;短命令要完整结果 →execFile;需要管道 / 通配符 →exec;跑Node模块要通信 →fork - 都是异步的,调用返回只代表进程起来了,结果要在回调或
close里拿
四个 API 本质上都是 spawn,区别只在于走不走 shell、输出缓不缓冲、带不带 IPC。 exec 帮你开 shell、把输出攒成字符串一次性给回调,方便但有注入和 maxBuffer 风险;execFile 一样给回调,但不开 shell,参数是数组,更安全;fork 专门跑 Node 脚本,自带父子通信通道;spawn 最底层,输出是流,适合长任务。同步版有 spawnSync、execSync、execFileSync,fork 没有同步版,因为它的意义就是长期通信。
注意事项:
- 下面列出来的都是异步创建子进程的方式,每一种方式都有对应的同步版本。
.exec()、.execFile()、.fork()底层都是通过.spawn()实现的。.exec()、execFile()额外提供了回调,当子进程停止的时候执行。
child_process.spawn(command[, args][, options]) child_process.exec(command[, options][, callback]) child_process.execFile(file[, args][, options][, callback]) child_process.fork(modulePath[, args][, options])
面试官追问
-
fork为什么没有同步版本?fork的价值是父子进程长期跑、互相发消息,同步等它结束就失去意义了。只想同步跑一个Node脚本拿结果,用execFileSync(process.execPath, ['script.js'])就行。 -
把
exec(cmd)改成execFile,能不能只换函数名?不能。
execFile不开shell,命令字符串得拆成可执行文件和参数数组:exec('git log -1')要写成execFile('git', ['log', '-1'])。原来依赖管道、通配符、环境变量展开的,换完就不工作了。 -
exec用Promise写怎么写?const { promisify } = require('util'); const execFile = promisify(require('child_process').execFile); const { stdout } = await execFile('git', ['rev-parse',
